{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "b4583ccd",
   "metadata": {},
   "source": [
    "#### Notebook style configuration (optional)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8e52f5e4",
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "from IPython.core.display import display, HTML\n",
    "style = open(\"./style.css\").read()\n",
    "display(HTML(\"<style>%s</style>\" % style))"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0ab5f738",
   "metadata": {},
   "source": [
    "# Animations\n",
    "\n",
    "\n",
    "<a href=\"https://github.com/rougier/pendulum\"><img src=\"https://raw.githubusercontent.com/rougier/pendulum/master/pendulum.gif\" width=\"33%\" align=\"left\" /></a>\n",
    "\n",
    "<a href=\"https://github.com/rougier/alien-life\"><img src=\"https://raw.githubusercontent.com/rougier/alien-life/master/alien-life.gif\" width=\"33%\" align=\"left\" /></a>\n",
    "\n",
    "<a href=\"https://github.com/rougier/windmap\"><img src=\"https://raw.githubusercontent.com/rougier/windmap/master/windmap.gif\" width=\"33%\" align=\"left\" /></a>\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3b98b540",
   "metadata": {},
   "source": [
    "Animation with matplotlib can be created very easily using the [animation framework](https://matplotlib.org/stable/api/animation_api.html). Furthermore, and because we are in the jupyter notebook, we can take advantage of its capability. "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "32fc8c8f",
   "metadata": {},
   "source": [
    "## 1. Simple animation\n",
    "\n",
    "Let's start with a very simple animation re-using our sine / cosine example."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f5a4b951",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "X = np.linspace(-np.pi, np.pi, 256, endpoint=True)\n",
    "C, S = np.cos(X), np.sin(X)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b1b31fc3",
   "metadata": {},
   "source": [
    "We want to make an animation where the sine and cosine are progressively drawn on the screen. To do that, we need first to tell matplotlib we want to make an animation and then, we have to specify what we watn to draw at each frame. One common mistake is to re-draw everything at each frame that makes the whole process very slow. Instead, we can only update what is necessary because we know that (in our case) a lot of things won't change from one frame to the other.\n",
    "\n",
    "For line plot, we'll thus use the `set_data` method to update the drawing and matplotlib will take care of of the rest."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "48ee6b82",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib nbagg\n",
    "import matplotlib.animation as animation\n",
    "\n",
    "fig = plt.figure(figsize=(7,2), dpi=100)\n",
    "ax = plt.subplot()\n",
    "\n",
    "line1, = ax.plot(X, C, marker=\"o\", markevery=[-1], markeredgecolor=\"white\")\n",
    "line2, = ax.plot(X, S, marker=\"o\", markevery=[-1], markeredgecolor=\"white\")\n",
    "\n",
    "def update(frame):\n",
    "    line1.set_data(X[:frame], C[:frame])\n",
    "    line2.set_data(X[:frame], S[:frame])\n",
    "\n",
    "ani = animation.FuncAnimation(fig, update, interval=10, frames=len(X));\n",
    "plt.show();"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9f0cc03a",
   "metadata": {},
   "source": [
    "Notice the end point marker that move with the animation. The reason is that we specify a single marker at the end ( `markevery=[-1]` ) such that each time we set new data, marker will automatically move.\n",
    "\n",
    "Let's pretend we're satisified with our animation, now it is time to save it. Matplotlib is able to create a movie file but options are rather scarce. A better solution is to use an external library such as [FFMpeg](https://www.ffmpeg.org/) which is available on most systems. Once installed, you will be able to use the dedicated `FFMpegWriter` as shown below:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "76cd87d2",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib nbagg\n",
    "import matplotlib.animation as animation\n",
    "\n",
    "fig = plt.figure(figsize=(7,2), dpi=100)\n",
    "ax = plt.subplot()\n",
    "\n",
    "line1, = ax.plot(X, C, marker=\"o\", markevery=[-1], markeredgecolor=\"white\")\n",
    "line2, = ax.plot(X, S, marker=\"o\", markevery=[-1], markeredgecolor=\"white\")\n",
    "\n",
    "def update(frame):\n",
    "    line1.set_data(X[:frame], C[:frame])\n",
    "    line2.set_data(X[:frame], S[:frame])\n",
    "\n",
    "writer = animation.FFMpegWriter(fps=30)\n",
    "anim = animation.FuncAnimation(fig, update, interval=10, frames=len(X));\n",
    "anim.save(\"../data/sine-cosine.mp4\", writer=writer, dpi=100);\n",
    "\n",
    "plt.show();"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ef077a1c",
   "metadata": {},
   "source": [
    "You may have noticed that the animation did not start immediately because there is actually a delay that corresponds to the movie creation. For sine and cosine, the delay is rather short and it is not really a problem. However, for long and complex animations, this delay can become quite significant and it becomes necessary to track its progress. So let's add some information using the [tqdm](https://github.com/tqdm/tqdm) library."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d5634b38",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib nbagg\n",
    "import matplotlib.animation as animation\n",
    "\n",
    "fig = plt.figure(figsize=(7,2), dpi=100)\n",
    "ax = plt.subplot()\n",
    "\n",
    "line1, = ax.plot(X, C, marker=\"o\", markevery=[-1], markeredgecolor=\"white\")\n",
    "line2, = ax.plot(X, S, marker=\"o\", markevery=[-1], markeredgecolor=\"white\")\n",
    "\n",
    "def update(frame):\n",
    "    line1.set_data(X[:frame], C[:frame])\n",
    "    line2.set_data(X[:frame], S[:frame])\n",
    "\n",
    "writer = animation.FFMpegWriter(fps=30)\n",
    "anim = animation.FuncAnimation(fig, update, interval=10, frames=len(X));\n",
    "\n",
    "from tqdm.autonotebook import tqdm\n",
    "bar = tqdm(total=len(X))\n",
    "anim.save(\"../data/sine-cosine.mp4\", writer=writer, dpi=300,\n",
    "          progress_callback = lambda i, n: bar.update(1))\n",
    "bar.close()\n",
    "\n",
    "plt.show();"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f69921f5",
   "metadata": {},
   "source": [
    "Creation time remains the same, but at least now, we can check how slow or fast it is. Let's see the result.\n",
    "\n",
    "<video width=\"100%\" controls src=\"../data/sine-cosine.mp4\" type=\"video/mp4\" />"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "ab1c62cc",
   "metadata": {},
   "source": [
    "## 2. Animation with real data\n",
    "\n",
    "A very simple rain effect can be obtained by having small growing rings randomly positioned over a figure. Of course, they won't grow forever since ripples are supposed to damp with time. To simulate this phenomenon, we can use an increasingly transparent color as the ring is growing, up to the point where it is no more visible. At this point, we remove the ring and create a new one.\n",
    "\n",
    "First step is to create a blank figure."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "cf4ea9a0",
   "metadata": {},
   "outputs": [],
   "source": [
    "%%capture\n",
    "\n",
    "# New figure with white background\n",
    "fig = plt.figure(figsize=(6,6), facecolor='white', dpi=50)\n",
    "\n",
    "# New axis over the whole figure, no frame and a 1:1 aspect ratio\n",
    "ax = fig.add_axes([0,0,1,1], frameon=False, aspect=1)\n",
    "ax.set_xlim(0,1), ax.set_xticks([])\n",
    "ax.set_ylim(0,1), ax.set_yticks([])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "94668b93",
   "metadata": {},
   "source": [
    "Then we create an empty scatter plot but we take care of settings linewidth (0.5) and facecolors (\"None\") that will apply to any new data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6de800da",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Empty scatter\n",
    "scatter = ax.scatter([], [], s=[], lw=0.5, edgecolors=[], facecolors=\"None\")"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "28f5f4a1",
   "metadata": {},
   "source": [
    "Next, we need to create several rings. For this, we can use the scatter plot object that is generally used to visualize points cloud, but we can also use it to draw rings by specifying we don't have a facecolor. We also have to take care of initial size and color for each ring such that we have all sizes between a minimum and a maximum size. In addition, we need to make sure the largest ring is almost transparent."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3b15c84a",
   "metadata": {
    "scrolled": false
   },
   "outputs": [],
   "source": [
    "n = 50\n",
    "R = np.zeros(n, dtype=[ (\"position\", float, (2,)),\n",
    "                        (\"size\",     float, (1,)),\n",
    "                        (\"color\",    float, (4,)) ])                       \n",
    "R[\"position\"] = np.random.uniform(0, 1, (n,2))\n",
    "R[\"size\"] = np.linspace(0, 1, n).reshape(n,1)\n",
    "R[\"color\"][:,3] = np.linspace(0, 1, n)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "bf359bc5",
   "metadata": {},
   "source": [
    "Now, we need to write the update function for our animation. We know that at each time step each ring should grow and become more transparent while the largest ring should be totally transparent and thus removed. Of course, we won't actually remove the largest ring but re-use it to set a new ring at a new random position, with nominal size and color. Hence, we keep the number of rings constant."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f5b7fe58",
   "metadata": {},
   "outputs": [],
   "source": [
    "def rain_update(frame):\n",
    "    global R, scatter\n",
    "\n",
    "    # Transparency of each ring is increased\n",
    "    R[\"color\"][:,3] = np.maximum(0, R[\"color\"][:,3] - 1/len(R))\n",
    "\n",
    "    # Size of each rings is increased\n",
    "    R[\"size\"] += 1/len(R)\n",
    "\n",
    "    # Reset last ring\n",
    "    i = frame % len(R)\n",
    "    R[\"position\"][i] = np.random.uniform(0, 1, 2)\n",
    "    R[\"size\"][i] = 0\n",
    "    R[\"color\"][i,3] = 1\n",
    "    \n",
    "    # Update scatter object accordingly\n",
    "    scatter.set_edgecolors(R[\"color\"])\n",
    "    scatter.set_sizes(1000*R[\"size\"].ravel())\n",
    "    scatter.set_offsets(R[\"position\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8f1d9116",
   "metadata": {},
   "source": [
    "Last step is to tell matplotlib to use this function as an update function for the animation and display the result (or save it as a movie). The whole script reads:"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f2bac18a",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib nbagg\n",
    "\n",
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.animation as animation\n",
    "\n",
    "def rain_update(frame):\n",
    "    global R, scatter\n",
    "\n",
    "    R[\"color\"][:,3] = np.maximum(0, R[\"color\"][:,3] - 1/len(R))\n",
    "    R[\"size\"] += 1/len(R)\n",
    "\n",
    "    i = frame % len(R)\n",
    "    R[\"position\"][i] = np.random.uniform(0,1,2)\n",
    "    R[\"size\"][i] = 0\n",
    "    R[\"color\"][i,3] = 1\n",
    "    \n",
    "    scatter.set_edgecolors(R[\"color\"])\n",
    "    scatter.set_sizes(1000*R[\"size\"].ravel())\n",
    "    scatter.set_offsets(R[\"position\"])\n",
    "    \n",
    "fig = plt.figure(figsize=(6,6), facecolor='white', dpi=100)\n",
    "ax = fig.add_axes([0,0,1,1], frameon=False, aspect=1)\n",
    "ax.set_xlim(0,1), ax.set_xticks([])\n",
    "ax.set_ylim(0,1), ax.set_yticks([])\n",
    "scatter = ax.scatter([], [], s=[], linewidth=0.5, edgecolors=[], facecolors=\"None\")\n",
    "\n",
    "n = 50\n",
    "R = np.zeros(n, dtype=[ (\"position\", float, (2,)),\n",
    "                        (\"size\",     float, (1,)),\n",
    "                        (\"color\",    float, (4,)) ])                       \n",
    "R[\"position\"] = np.random.uniform(0, 1, (n,2))\n",
    "R[\"size\"] = np.linspace(0, 1, n).reshape(n,1)\n",
    "R[\"color\"][:,3] = np.linspace(0, 1, n)\n",
    "\n",
    "animation = animation.FuncAnimation(fig, rain_update, interval=10, frames=200);"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "47d9fd66",
   "metadata": {},
   "source": [
    "We'll now use the rain animation to visualize earthquakes on the planet from the last 30 days. The USGS Earthquake Hazards Program is part of the National Earthquake Hazards Reduction Program (NEHRP) and provides several data on their website. Those data are sorted according to earthquakes magnitude, ranging from significant only down to all earthquakes, major or minor. You would be surprised by the number of minor earthquakes happening every hour on the planet. Since this would represent too much data for us, we'll stick to earthquakes with magnitude > 4.5. At the time of writing, this already represent more than 300 earthquakes in the last 30 days."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "cfa6a663",
   "metadata": {},
   "source": [
    "First step is to read and convert data. We'll use the urllib library that allows us to open and read remote data. Data on the website use the CSV format whose content is given by the first line:\n",
    "\n",
    "> time,latitude,longitude,depth,mag,magType,nst,gap,dmin,rms,net,id,updated,place,type\n",
    "> 2015-08-17T13:49:17.320Z,37.8365,-122.2321667,4.82,4.01,mw,...\n",
    "> 2015-08-15T07:47:06.640Z,-10.9045,163.8766,6.35,6.6,mwp,...\n",
    "\n",
    "We are only interested in latitude, longitude and magnitude and we won't parse time of event."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "6bd8e317",
   "metadata": {},
   "outputs": [],
   "source": [
    "import urllib\n",
    "import numpy as np\n",
    "\n",
    "# -> http://earthquake.usgs.gov/earthquakes/feed/v1.0/csv.php\n",
    "feed = \"http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/\"\n",
    "\n",
    "# Magnitude > 4.5\n",
    "url = urllib.request.urlopen(feed + \"4.5_month.csv\")\n",
    "\n",
    "# Magnitude > 2.5\n",
    "# url = urllib.request.urlopen(feed + \"2.5_month.csv\")\n",
    "\n",
    "# Magnitude > 1.0\n",
    "# url = urllib.request.urlopen(feed + \"1.0_month.csv\")\n",
    "\n",
    "# Reading and storage of data\n",
    "data = url.read().split(b'\\n')[+1:-1]\n",
    "E = np.zeros(len(data), dtype=[('position',  float, (2,)),\n",
    "                               ('magnitude', float, (1,))])\n",
    "\n",
    "for i in range(len(data)):\n",
    "    row = data[i].split(b',')\n",
    "    E['position'][i] = float(row[2]),float(row[1])\n",
    "    E['magnitude'][i] = float(row[4])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9b89c293",
   "metadata": {},
   "source": [
    "Now, we need to draw the earth on a figure to show precisely where the earthquake center is and to translate latitude/longitude in some coordinates matplotlib can handle. Fortunately, there is the [basemap](https://matplotlib.org/basemap/) toolkit that is really simple to install and to use.\n",
    "\n",
    "First step is to define a projection to draw the earth onto a screen (there exists many different projections) and we'll stick to the mill projection which is rather standard for non-specialist like me."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "11f355df",
   "metadata": {
    "scrolled": true
   },
   "outputs": [],
   "source": [
    "%matplotlib inline\n",
    "import cartopy.crs as ccrs\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "fig = plt.figure(figsize=(10,5), dpi=75)\n",
    "ax = plt.axes(projection=ccrs.PlateCarree())\n",
    "ax.coastlines()\n",
    "\n",
    "plt.show();"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "f86a8e55",
   "metadata": {},
   "source": [
    "We can now adapt our rain animation to display eartquakes. Note that we add a `transform` to the scatter plot such that coordinates will be automatically transformed."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4432fa15",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib nbagg\n",
    "\n",
    "import cartopy.crs as ccrs\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.animation as animation\n",
    "\n",
    "def rain_update(frame):\n",
    "    global R, scatter\n",
    "    \n",
    "    current = frame % len(E)\n",
    "    i = frame % len(R)\n",
    "\n",
    "    R[\"color\"][:,3] = np.maximum(0, R[\"color\"][:,3] - 1/len(R))\n",
    "    R[\"size\"] += R[\"growth\"]\n",
    "\n",
    "    i = frame % len(R)\n",
    "    R[\"position\"][i] = E[\"position\"][current]\n",
    "    R[\"size\"][i] = 5\n",
    "    R[\"growth\"][i] = 0.1*np.exp(E[\"magnitude\"][current])\n",
    "    R[\"color\"][i,3] = 1\n",
    "    \n",
    "    scatter.set_edgecolors(R[\"color\"])\n",
    "    scatter.set_sizes(R[\"size\"].ravel())\n",
    "    scatter.set_offsets(R[\"position\"])\n",
    "    \n",
    "fig = plt.figure(figsize=(10,5), dpi=75)\n",
    "ax = plt.axes(projection=ccrs.PlateCarree())\n",
    "ax.coastlines()\n",
    "scatter = ax.scatter([], [], s=[],  transform=ccrs.PlateCarree(),\n",
    "                     linewidth=0.5, edgecolors=[], facecolors=\"None\")\n",
    "\n",
    "n = 50\n",
    "R = np.zeros(n, dtype=[ (\"position\", float, (2,)),\n",
    "                        (\"size\",     float, (1,)),\n",
    "                        (\"growth\",     float, (1,)),\n",
    "                        (\"color\",    float, (4,)) ])                       \n",
    "R[\"position\"] = np.random.uniform(0, 1, (n,2))\n",
    "R[\"size\"] = np.linspace(0, 1, n).reshape(n,1)\n",
    "R[\"color\"][:,3] = np.linspace(0, 1, n)\n",
    "\n",
    "animation = animation.FuncAnimation(fig, rain_update, interval=10, frames=200);"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dcf6fc0b",
   "metadata": {},
   "source": [
    "You can now enrich the animation with various features. For example, you could use a transparent red face color for earthquakes with magnitude greater than 7 or 8. You could also take into account the time of earthquake to set data at a precise, time, etc."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "820981cb",
   "metadata": {},
   "source": [
    "## 3. Scenarized animation\n",
    "\n",
    "We've seen the basic principles of animation. It is now time to define a more elaborated scenario for our animation. To do that, we'll play with fluid simulation because it's fun. In [fluid.py](./fluid.py) you'll find an implementation of stable fluids written by [Gregory Johnson](https://github.com/GregTJ/stable-fluids) based on the paper of [Joe Stam](https://d2f99xq7vri1nk.cloudfront.net/legacy_app_files/pdf/ns.pdf). "
   ]
  },
  {
   "cell_type": "markdown",
   "id": "1bdfca1d",
   "metadata": {},
   "source": [
    "I've modified the original script and written an `inflow` method that define a source at a given position (angle). At each frame, we want to define active sources such that the overal animation displays a sequence of emitting sources.\n",
    "\n",
    "In the scenario below, I define artibtrarily a rotating sequence of sources to maximize blending in the center but you could also imagine synchronizing this animation with some music for example."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d269af08",
   "metadata": {},
   "outputs": [],
   "source": [
    "%matplotlib nbagg\n",
    "\n",
    "import numpy as np\n",
    "from fluid import Fluid, inflow\n",
    "from scipy.special import erf\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.animation as animation\n",
    "\n",
    "shape = 256, 256\n",
    "duration = 500\n",
    "fluid = Fluid(shape, 'dye')\n",
    "inflows = [inflow(fluid, x)\n",
    "           for x in np.linspace(-np.pi, np.pi, 8, endpoint=False)]\n",
    "    \n",
    "# Animation setup\n",
    "fig = plt.figure(figsize=(5, 5), dpi=100)\n",
    "ax = fig.add_axes([0, 0, 1, 1], frameon=False)\n",
    "ax.set_xlim(0, 1); ax.set_xticks([]);\n",
    "ax.set_ylim(0, 1); ax.set_yticks([]);\n",
    "im = ax.imshow( np.zeros(shape), extent=[0, 1, 0, 1],\n",
    "                vmin=0, vmax=1, origin=\"lower\",\n",
    "                interpolation='bicubic', cmap=plt.cm.RdYlBu)\n",
    "\n",
    "# Animation scenario\n",
    "scenario = []\n",
    "for i in range(8):\n",
    "    scenario.extend( [[i]]*20 )\n",
    "scenario.extend([[0,2,4,6]]*30)\n",
    "scenario.extend([[1,3,5,7]]*30)\n",
    "\n",
    "# Animation update\n",
    "def update(frame):\n",
    "    frame = frame % len(scenario)\n",
    "    for i in scenario[frame]:\n",
    "        inflow_velocity, inflow_dye = inflows[i]\n",
    "        fluid.velocity += inflow_velocity\n",
    "        fluid.dye += inflow_dye\n",
    "    divergence, curl, pressure = fluid.step()\n",
    "    Z = curl\n",
    "    Z = (erf(Z * 2) + 1) / 4\n",
    "\n",
    "    im.set_data(Z)\n",
    "    im.set_clim(vmin=Z.min(), vmax=Z.max())\n",
    "\n",
    "anim = animation.FuncAnimation(fig, update, interval=10, frames=duration)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "6ab1f2ad",
   "metadata": {},
   "source": [
    "Note that in the update function, I took care of updating the limits of the colormap. This is necessary because the displayed image is dynamic and the minimum and maximum values may vary from one frame ot the other. If you don't do that, you might see some flickering appearing."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c2aaf65e",
   "metadata": {},
   "source": [
    "----\n",
    "\n",
    "**Copyright (c) 2021 Nicolas P. Rougier**  \n",
    "This work is licensed under a [Creative Commons Attribution 4.0 International License](http://creativecommons.org/licenses/by/4.0/).\n",
    "<br/>\n",
    "Code is licensed under a [2-Clauses BSD license](https://opensource.org/licenses/BSD-2-Clause)"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.8.10"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
